
同一份手冊、同一個問題、同一行時間戳,我在 M4 Max 上只換了一件事:那行 當前時間:2026-08-28 19:41:07.842 放在 prompt 開頭,還是放在結尾。
放第一行,llama-server 要重算 6,494 個 token、花 15.7 秒;放最後,只剩 31 個、195 ms。
那行時間自己只讓 prompt 多 28 個 token。貴的從來不是它,是它把 prefix cache 的斷點推到了哪裡。
等待的第一段是 prefill:模型先把整段 prompt 算成後面生成要用的 KV。相同位置上的相同 token,算過的結果就有機會直接接手。
規則很笨也很簡單:**從第一個 token 往後比,能連續對上多少就重用多少;第一個不同的 token 就是斷點。**斷點之後全部重算,哪怕後面 99% 一字不差。
所以 固定內容 → 固定內容 → 時間戳 能一路重用到最後一格;時間戳 → 固定內容 → 固定內容 第一個 token 就斷了,後面全部救不回來。
它只縮 prefill、完全不動 decode(vLLM 的 Automatic Prefix Caching 官方文件明言這一點);不同 runtime 的比對粒度不完全一樣,細節放附錄。

圖 1:時間戳排在第一行,斷點就落在第 0 個 token——6,494 個裡沒有一個接得回來。
四發請求同 process 連打,只差可變的東西擺哪裡:
| 第幾發 | 差別 | 要重算的 token | prefill |
|---|---|---|---|
| ① | 第一次,全冷 | 6,466 | 13,582 ms |
| ② | 一字不差再打一次 | 1 | 38.7 ms |
| ③ | 時間戳放最前面 | 6,494 | 15,671 ms |
| ④ | 同一個時間戳放最後面 | 31 | 195 ms |
T1 M4 Max 128GB、llama.cpp b10488、Gemma 4 12B QAT Q4_0,兩輪中位數。完整參數與重現指令在附錄。
② 的那個 1 是 llama-server 的最低重算量:就算整份 prompt 完整命中,它仍會把最後一個 token 重新算一次。完整命中不等於零計算。
③④ 才是要帶走的那一格。同一個時間戳、同一份文件,只換位置:重算的 token 6,494 → 31,209 倍;prefill 15,671 → 195 ms,80 倍。而 ③ 比全冷的 ① 還多 28 個 token,多出來的正是時間戳自己——把會變的東西放最前面,不是沒省到,是比從頭算還多算一截。
兩個倍數對不起來。token 少了 209 倍,時間只少 80 倍。因為那 31 個 token 不是從空 context 開始算,它仍接在六千多個既有 context 後面;短 batch 的固定成本也更難攤。所以 cache 命中的 token 比例,不能直接當成加速比。
Day 01 那台 45 秒的機器就是這樣:每問一題檢索一次,回傳的 chunk 每次不同,還按分數重排。
上一題是 [A,B,C],下一題變成 [A,B,D],cache 至少還吃得到 A 跟 B;但只要 reranker 把它排成 [C,A,B],斷點就直接跑到第一個 chunk。檢索結果夠穩的服務照樣命中得到,打掉 cache 的是動態的 chunk 內容與順序。
而 chunk 通常擺在 system prompt 之後、使用者問題之前,落在整條 prompt 的中段偏前。斷在那裡,它後面的東西全部得重算——實際哪些遭殃,取決於你的 prompt 怎麼排。
修法一句話:穩定前綴,可變後綴。system prompt、固定規則、tools schema、固定 few-shot 盡量形成穩定前綴;時間戳、request ID、動態 metadata,以及其他能安全後移的動態內容,在不改變語義與指令優先級的前提下盡量往後。

圖 2:可以截圖帶走的排法——第 5 到第 7 項每次都會變,所以只能待在線以下。
| Runtime | 不要看 | 判命中看 |
|---|---|---|
| llama-server | — | cache_n 跳上來、prompt_n 掉下來 |
| Ollama | prompt_eval_count |
prompt_eval_duration 掉下來 |
Ollama 最容易看錯。同一發 ④,API 回我 prompt_eval_count: 6509,runner 自己的 log 寫的卻是 prompt eval time = 1130.08 ms / 1053 tokens——**6,509 是 prompt 有多長,不是這次重新算了多少。**拿它判命中,你會永遠得到「沒命中」。

圖 3:三個數字都對,只是在回答不同的問題。
讀錯欄位的人不會看到錯誤訊息,只會得到一個穩定而且錯誤的結論。
(小插曲:同一行時間戳換個位置,連 tokenizer 的切分邊界都會跟著變,28 個 token 會變成 29 個。細節在附錄。)
15,671 → 195 ms 這一刀,單人當然有感。但 prefix cache 更大的價值,是不要讓十個、一百個請求重複燒同一段 prefill 算力。
昨天 Top-30 的那份 24K prompt,M4 Max 估算的 91.2 秒裡有 81.6 秒都在 prefill(照實測 pp 曲線內插換算,不是直接量到的秒數)。這種 workload 一旦前綴大量命中,省掉的就不只是某個人的等待,而是所有人共享的 prefill 算力。別用單人 benchmark 決定要不要做 prefix cache。
十分鐘就夠:同一份長 prompt 打兩次,llama-server 看 cache_n 有沒有跳上來、prompt_n 有沒有掉下去,Ollama 看 prompt_eval_duration 有沒有大幅下降。再把你的 RAG prompt 印出來,看時間戳、request ID、動態 metadata 是不是塞在最前面。完整指令在附錄。
明天 Day 19 換一個問題:軟體端三刀都砍完還是不夠的時候,該換什麼機器。輪到 Mac Studio M5 Ultra 用自己的尺量一次。
咱們明天見。
llama-server:-ngl 99 -fa off -c 16384 -np 1 --cache-reuse 256(本輪被 context 停用,見下)、n_predict 8、temperature 0、seed 42。Ollama 0.32.15 跑 gemma4:26b(Gemma 4 26B-A4B、Q4_K_M)、num_ctx 16384、OLLAMA_NUM_PARALLEL=1,它自己起 runner 帶的是 --flash-attn auto -b 1024 -ub 1024 --context-shift。四發同 process 連打、順序固定 ①②③④,兩輪中位數,量測時機器上有前景程式(load 2.0–4.2)。
冷 → 熱那一格:llama-server 的 prefill 是 13,582 → 38.7 ms,351 倍;② 的 server log 寫著 need to evaluate at least 1 token for each active slot,於是 n_past 被退回 6,465,最後那一個 token 真的重算了一次。
短批貴在哪裡:③ 的 prefill 跑 414 tok/s,④ 只剩 159。同一顆模型、同一台機器,差別只是一批 6,494 個 token 與一批 31 個。
Ollama 那組四發的 prompt_eval_count 是 6,481/6,481/6,509/6,509(一格沒動),prompt_eval_duration 是 4,866/16.7/5,743/1,072 ms —— 冷熱 291 倍,時間戳前後只有 5.4 倍。它跟正文那個 80 倍不是同一場比賽:llama-server 那顆是 12B dense 的 Q4_0、-fa off,Ollama 那顆是 26B-A4B 的 Q4_K_M、--flash-attn auto,連 batch 大小都不一樣。兩列不可互相相除,只能各自比自己的冷熱(Day 08 判定過跨 stack 直比是錯誤示範)。
還有一個口徑別記錯:上面量的是 prompt_ms 與 prompt_eval_duration,是 prefill 的內部計時,不是 TTFT。② 命中後 prefill 只剩 38.7 ms,那一發的總時間卻是 201 ms——命中之後,決定使用者等多久的已經不是 prefill 了。
# 造三份 prompt:base 沒有時間戳,front/back 只差它擺哪。本文完全相同(約 6.5K token)
python3 - <<'PY'
import json
doc = open('manual.txt').read() # 換成你自己的長文件
q = "\n\n問題:PX-450 主軸該用哪顆螺絲、扭力多少?"
ts = "當前時間:2026-08-28 19:41:07.842\n"
for k, p in {"base": doc + q, "front": ts + doc + q,
"back": doc + q + "\n" + ts}.items():
json.dump({"prompt": p, "n_predict": 8, "temperature": 0, "seed": 42,
"cache_prompt": True}, open(f"req-{k}.json", "w"), ensure_ascii=False)
json.dump({"model": "gemma4:26b", "prompt": p, "stream": False,
"options": {"temperature": 0, "seed": 42, "num_predict": 8,
"num_ctx": 16384}}, open(f"oll-{k}.json", "w"), ensure_ascii=False)
PY
# ① llama-server:base 打兩次看冷→熱,再看時間戳擺哪。-c 一定要蓋過 prompt,不然會被靜默截斷
./llama-server -m gemma-4-12b-it-qat-q4_0.gguf -c 16384 -np 1 -ngl 99 -fa off --port 8099 &
for R in base base front back; do
curl -s localhost:8099/completion -d @req-$R.json \
| jq '.timings|{cache_n, prompt_n, prompt_ms}'
done
# ② Ollama:看 prompt_eval_duration,不是 count。量冷啟前先 stop,num_ctx 自己給滿
export OLLAMA_NUM_PARALLEL=1
ollama stop gemma4:26b && sleep 3
for R in base base front back; do
curl -s localhost:11434/api/generate -d @oll-$R.json \
| jq '{prompt_eval_count, prompt_eval_duration}'
done
grep "prompt eval time" ~/.ollama/logs/server.log | tail -4 # runner 真正算了幾個
# ③ vLLM APC(本輪未量:T2 連線 timeout)。counter 是 token 級累計,時間要自己計
vllm serve 你的模型 --max-model-len 16384 --enable-prefix-caching
curl -s localhost:8000/metrics | grep -E "prefix_cache_(queries|hits)"
cache_n / prompt_n:兩格一起看cache_n 是這一發直接接回幾個 token,prompt_n 是真的重新算了幾個。①全冷是 0/6,466,②一字不差是 6,465/1,④時間戳放最後是 6,464/31——本輪這四發,兩格相加都等於整份 prompt 的長度。
④ 加起來是 6,495,比 ③ 的 6,494 多一個,這不是量錯。llama-tokenize 實測:同一行時間戳貼在最前面讓 prompt 多 28 個 token,接在文件後面卻多 29 個,中間那個換行把切分邊界推掉一格。tokenizer 是對整串文字切的,換個位置連它自己的長度都會變。
Ollama 那邊的 ④ 也不是全 miss:runner 實際算了 1,053 個、重用了 5,456 個,83.8% 沒有重算。我原本以為這輪只量到「近乎全中」與「全不中」兩種,是錯的,只是 API 不告訴你。
Ollama 0.32.15 在這顆 GGUF 上起的 runner 就是 llama.cpp 的 llama-server(log 寫著 using llama-server for model,後面接完整命令列)。兩顆跑 --version 甚至報同一個 commit 9d77fa172,只差 build 編號(我的 10488、它的 1)與編譯器小版本。所以 cache 機制是同一套,差別在啟動參數,以及 API 轉不轉出欄位。
| 機制 | 作用範圍 | 判命中看哪一欄 |
|---|---|---|
| llama-server(預設就開) | slot 內的 KV,加上 --cache-ram(預設 8192 MiB)存在主記憶體的 prompt cache 與 context checkpoint;會跨請求回頭找更好的前綴 |
timings.cache_n/prompt_n |
| Ollama 0.32.15 | 同上;底層是它自己 bundle 的 llama-server build,啟動參數與上列不同 | 判冷熱看 prompt_eval_duration;API 不提供本輪實算的 token 數,要讀 runner log |
| vLLM APC | block hash(一般單一 attention cache group 下預設 16 tokens 一塊),跨請求、跨併發共享 | prefix_cache_queries/prefix_cache_hits |

圖 4:同一組冷熱,四個欄位裡只有一個不動——偏偏是最多人拿來判命中的那個。
作用範圍比「當前這個 slot」大得多。我的第四發排在第三發之後,而第三發的前綴從第 0 個 token 就斷了,第四發卻仍然接回 6,464 個 token——第一、二發留下的東西沒有被沖掉。
Ollama 那邊同一發看得更清楚。log 先寫 found better prompt with f_keep = 0.998:它翻了一遍 prompt cache,撈回兩發之前留下的 state,共同前綴有 6,476 個 token。但這次真正還原得回來的只到 restored context checkpoint (pos_max = 5456),所以 5,456 之後那 1,053 個還是得重算——其中 1,020 個明明落在共同前綴裡,只是沒被還原的 state 蓋到。找到共同前綴,不代表整段共同前綴都恢復得回來。
vLLM 那一列還有一層落差:共同前綴不足一塊時效益直接是零;差異落在某一塊裡時,那一塊與它後面的都不能沿用,前面完整對上的塊仍然算數。
⚠️ hybrid/多 cache group 另有 prefix_match_unit(官方定義是「前綴命中能落到的最細 token 邊界」),粒度不一定永遠是 16,動手前查你那版。那兩條 counter 累計的是查了多少 prefix token、其中多少命中,可以算 token 級命中率,但不會告訴你這一發省了幾毫秒。
至於想救斷點之後那一段的 --cache-reuse 256,這組 model/context 不支援:log 寫著 cache_reuse is not supported by this context, it will be disabled。不是這台機器跑不動,是它自己把功能關掉,所以這一輪量不到。
還有,量 Ollama 的冷啟之前先 ollama stop。runner 沒卸載,上一輪的 cache 跨 CLI 呼叫還活著:我第二輪的「冷」只花 72.6 ms,量到的是上一次的 cache。
SGLang RadixAttention 的 6.4x 量的是 throughput【引用,arXiv 2312.07104】,Prompt Cache 論文的 8x 是另一種可重組非前綴片段的機制【引用,arXiv 2311.04934】,vLLM APC 官方文件則從頭到尾沒給過倍數。你的倍數只能自己量。